S3 Presigned URL로 업로드 부하 분산하기
S3 Presigned URL로 업로드 부하 분산하기
Presigned URL을 사용하면 애플리케이션 서버는 업로드 권한과 객체 키만 발급하고, 파일 바이트는 브라우저가 S3로 직접 전송한다. 서버 부하는 줄지만 URL은 만료 전까지 해당 작업을 수행할 수 있는 bearer credential에 가깝다. 짧은 만료 시간, 예측 불가능한 키, 최소 IAM 권한, 크기·형식 제한, 업로드 완료 검증을 함께 설계해야 한다.
목차
- #애플리케이션 서버를 거치는 업로드의 비용
- #Presigned URL 업로드 흐름
- #URL을 발급하기 전에 결정할 것
- #서버에서 PUT Presigned URL 발급하기
- #브라우저에서 S3로 직접 업로드하기
- #업로드 완료를 별도로 확정해야 하는 이유
- #PUT과 POST 중 무엇을 선택할까
- #보안 경계를 URL 하나로 착각하지 않기
- #파일 검증과 후처리 파이프라인
- #CORS와 자주 만나는 실패
- #대용량 파일은 Multipart Upload를 고려한다
- #테스트와 운영 지표
- #정리
- #관련 노트와 참고 자료
애플리케이션 서버를 거치는 업로드의 비용
가장 단순한 업로드 API는 브라우저가 파일을 애플리케이션 서버로 보내고 서버가 다시 S3에 저장하는 구조다.
flowchart LR
A[Browser] -->|500MB 업로드| B[Application API]
B -->|다시 500MB 전송| C[(Amazon S3)]이 구조가 항상 잘못된 것은 아니다. 서버가 업로드 스트림을 즉시 검사하거나 변환해야 한다면 필요할 수 있다. 다만 일반적인 원본 파일 보관에서는 다음 비용이 생긴다.
- 같은 바이트가
브라우저 → API,API → S3두 네트워크 구간을 지난다. - API 인스턴스의 connection, bandwidth, socket이 업로드 시간 동안 점유된다.
- body parser나 잘못된 buffering 설정으로 큰 파일이 메모리에 올라갈 수 있다.
- 업로드가 길어질수록 load balancer와 reverse proxy timeout에 걸릴 가능성이 커진다.
- 인스턴스를 scale out해도 파일 중계 비용이 함께 증가한다.
100명의 사용자가 각각 200MB를 동시에 올리면 API가 중계해야 할 입력만 20GB다. S3로 내보내는 트래픽도 별도로 발생한다. 비즈니스 로직이 파일 바이트를 읽을 필요가 없다면 API를 데이터 경로에서 제외할 수 있다.
Presigned URL은 AWS 자격 증명을 클라이언트에 전달하지 않고, 특정 S3 작업을 제한된 시간 동안 수행할 수 있게 서명한 요청이다.
API 서버가 파일을 운반하는 서버에서 업로드의 권한과 상태를 관리하는 서버로 바뀐다.
Presigned URL 업로드 흐름
전체 흐름은 보통 세 단계다.
sequenceDiagram
participant U as Browser
participant API
participant DB
participant S3
U->>API: 1. 업로드 세션 요청(name, size, type)
API->>DB: 권한 확인 + PENDING 세션 저장
API-->>U: objectKey + presigned URL
U->>S3: 2. 파일 PUT
S3-->>U: ETag / 성공
U->>API: 3. 업로드 완료 요청
API->>S3: HeadObject로 확인
API->>DB: READY 상태로 전이
API-->>U: attachmentId각 단계의 책임은 다르다.
- 업로드 준비: 인증된 사용자인지, 이 기능을 사용할 권한이 있는지, 허용된 크기와 형식인지 검사한다. 서버가 객체 키를 만들고
PENDING레코드를 저장한다. - 바이트 전송: 브라우저가 URL을 사용해 S3에 직접 PUT한다. API 서버는 바이트를 보지 않는다.
- 완료 확정: 클라이언트의 성공 주장만 믿지 않고 S3 객체의 크기와 메타데이터를 확인한다. 이후 비동기 검사 대상으로 보낸다.
URL 발급과 DB 레코드 생성 순서도 중요하다. DB에 업로드 의도를 남기지 않고 URL만 발급하면 어떤 사용자가 어떤 객체를 만들었는지 추적하기 어렵다.
URL을 발급하기 전에 결정할 것
업로드 준비 요청에는 파일 자체가 아니라 서버가 정책을 판단하는 데 필요한 메타데이터만 보낸다.
{
"fileName": "profile-photo.png",
"contentType": "image/png",
"size": 1843200,
"purpose": "PROFILE_IMAGE"
}
DTO는 기본 모양을 검사한다.
export class PrepareUploadDto {
@IsString()
@Length(1, 180)
fileName!: string;
@IsIn(["image/png", "image/jpeg", "application/pdf"])
contentType!: string;
@IsInt()
@Min(1)
@Max(10 * 1024 * 1024)
size!: number;
@IsIn(["PROFILE_IMAGE", "DOCUMENT"])
purpose!: "PROFILE_IMAGE" | "DOCUMENT";
}
그러나 클라이언트가 보낸 contentType과 size는 아직 주장일 뿐이다. 실제 객체를 올린 후 다시 확인해야 한다.
객체 키는 서버가 만든다
원본 파일명을 그대로 S3 key로 사용하면 문제가 생긴다.
- 같은 이름이 충돌하고 기존 객체를 덮어쓸 수 있다.
- 사용자 입력의
/,.., 제어 문자로 운영 도구가 혼란스러워진다. - 이메일이나 이름이 key에 포함되면 로그와 URL에 개인정보가 남는다.
- 순차 ID는 다른 사용자의 key를 추측하기 쉽다.
서버가 소유 범위와 무작위 ID를 조합한다.
function createObjectKey(input: {
userId: string;
extension: "png" | "jpg" | "pdf";
}): string {
const uploadId = crypto.randomUUID();
return [
"private",
"uploads",
input.userId,
`${uploadId}.${input.extension}`,
].join("/");
}
원본 파일명은 DB 메타데이터로 따로 저장하고 다운로드 응답의 Content-Disposition에 사용할 때 안전하게 인코딩한다.
.png라는 이름과 image/png 헤더만으로 실제 PNG 파일임을 보장할 수 없다. Magic bytes 검사와 디코딩, 악성코드 검사는 업로드 후 격리 구역에서 수행한다.
서버에서 PUT Presigned URL 발급하기
AWS SDK for JavaScript v3를 사용하는 가상의 NestJS 서비스를 구성해 보자.
import {
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
type PrepareUploadCommand = {
userId: string;
originalName: string;
contentType: string;
size: number;
purpose: "PROFILE_IMAGE" | "DOCUMENT";
};
class UploadPreparationService {
constructor(
private readonly s3: S3Client,
private readonly uploads: UploadRepository,
private readonly bucketName: string,
) {}
async prepare(command: PrepareUploadCommand): Promise<PreparedUpload> {
const policy = resolveUploadPolicy(command.purpose);
policy.assertAllowed(command.contentType, command.size);
const uploadId = crypto.randomUUID();
const extension = extensionFor(command.contentType);
const objectKey =
`quarantine/${command.userId}/${uploadId}.${extension}`;
await this.uploads.createPending({
id: uploadId,
userId: command.userId,
objectKey,
originalName: command.originalName,
expectedContentType: command.contentType,
expectedSize: command.size,
purpose: command.purpose,
expiresAt: addMinutes(new Date(), 10),
});
const putCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: objectKey,
ContentType: command.contentType,
Metadata: {
"upload-id": uploadId,
},
});
const url = await getSignedUrl(this.s3, putCommand, {
expiresIn: 5 * 60,
});
return {
uploadId,
objectKey,
method: "PUT",
url,
headers: {
"Content-Type": command.contentType,
},
expiresInSeconds: 300,
};
}
}
코드는 실제 프로젝트에서 복사한 것이 아니라 흐름을 설명하기 위한 재구성 예제다. 핵심은 서명한 명령과 클라이언트 요청이 일치해야 한다는 점이다. ContentType을 서명에 포함했다면 브라우저도 동일한 Content-Type을 보내야 한다.
API 응답은 다음처럼 URL과 필요한 헤더를 명시적으로 함께 준다.
{
"uploadId": "5c65c7c8-41bd-4bb6-b197-36c8d8e9ecaf",
"method": "PUT",
"url": "https://example-bucket.s3.ap-northeast-2.amazonaws.com/...",
"headers": {
"Content-Type": "image/png"
},
"expiresInSeconds": 300
}
클라이언트가 SDK 세부사항을 추측하지 않게 method, headers, 만료 시간을 계약에 넣는다.
URL 발급 권한과 업로드 권한은 다르다
URL을 만드는 계산 자체가 S3에 네트워크 요청을 보내는 것은 아닐 수 있다. 그러나 서명에 사용한 IAM principal이 실제 PutObject 권한을 가져야 요청 시점에 S3가 허용한다. Presigned URL은 서명 principal이 갖지 않은 권한을 새로 만들어 내지 못한다.
서명 역할에는 가능한 한 특정 bucket과 prefix의 필요한 action만 허용한다.
{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::example-private-bucket/quarantine/*"
}
목록 조회, bucket 삭제, 다른 prefix 읽기 권한까지 함께 주지 않는다.
브라우저에서 S3로 직접 업로드하기
클라이언트는 API가 준 메서드와 헤더로 요청한다.
type PreparedUpload = {
uploadId: string;
url: string;
method: "PUT";
headers: Record<string, string>;
};
async function uploadFile(
prepared: PreparedUpload,
file: File,
): Promise<void> {
const response = await fetch(prepared.url, {
method: prepared.method,
headers: prepared.headers,
body: file,
});
if (!response.ok) {
throw new UploadFailedError({
status: response.status,
});
}
}
Presigned URL 전체에는 서명 파라미터가 포함되므로 로그나 오류 추적 도구에 그대로 남기지 않는다.
// 잘못된 예
logger.error({ uploadUrl: prepared.url, error });
// 더 나은 예
logger.error({
event: "direct_upload.failed",
uploadId: prepared.uploadId,
status: error.status,
});
브라우저의 업로드 진행률이 필요하면 XMLHttpRequest 또는 진행 이벤트를 지원하는 클라이언트를 사용할 수 있다.
function uploadWithProgress(
prepared: PreparedUpload,
file: File,
onProgress: (ratio: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("PUT", prepared.url);
for (const [name, value] of Object.entries(prepared.headers)) {
request.setRequestHeader(name, value);
}
request.upload.onprogress = (event) => {
if (event.lengthComputable) {
onProgress(event.loaded / event.total);
}
};
request.onload = () =>
request.status >= 200 && request.status < 300
? resolve()
: reject(new UploadFailedError({ status: request.status }));
request.onerror = () => reject(new NetworkError());
request.send(file);
});
}
업로드 완료를 별도로 확정해야 하는 이유
URL을 발급했다는 사실은 파일이 업로드되었다는 뜻이 아니다. 사용자가 화면을 닫거나 네트워크가 끊기면 PENDING 레코드만 남는다. 반대로 브라우저가 성공했다고 보고해도 서버는 최종 객체를 검증해야 한다.
완료 API는 uploadId를 받아 소유권과 상태를 확인하고 S3 HeadObject 결과를 비교한다.
import { HeadObjectCommand } from "@aws-sdk/client-s3";
class CompleteUploadService {
async complete(
userId: string,
uploadId: string,
): Promise<CompletedUpload> {
const upload = await this.uploads.findById(uploadId);
if (!upload || upload.userId !== userId) {
throw new UploadNotFoundError();
}
if (upload.status === "READY") {
return upload;
}
const object = await this.s3.send(
new HeadObjectCommand({
Bucket: this.bucketName,
Key: upload.objectKey,
}),
);
if (object.ContentLength !== upload.expectedSize) {
throw new UploadSizeMismatchError();
}
if (object.ContentType !== upload.expectedContentType) {
throw new UploadTypeMismatchError();
}
if (object.Metadata?.["upload-id"] !== upload.id) {
throw new UploadMetadataMismatchError();
}
return this.uploads.markUploaded(upload.id, {
etag: object.ETag ?? null,
uploadedAt: new Date(),
});
}
}
ETag를 곧바로 파일의 MD5라고 가정하지 않는다. Multipart upload나 암호화 방식에 따라 의미가 달라질 수 있다. 무결성이 중요하면 지원되는 checksum 헤더와 S3 검증 방식을 명시적으로 사용한다.
완료 API도 중복 호출될 수 있으므로 멱등하게 만든다. 이미 READY 또는 SCANNING 상태라면 기존 결과를 반환한다.
stateDiagram-v2
[*] --> PENDING: URL 발급
PENDING --> UPLOADED: HeadObject 확인
UPLOADED --> SCANNING: 검사 작업 시작
SCANNING --> READY: 안전 판정
SCANNING --> REJECTED: 형식/악성코드 실패
PENDING --> EXPIRED: 제한 시간 경과오래된 PENDING 객체와 레코드는 정리 작업이나 S3 Lifecycle rule로 제거한다.
PUT과 POST 중 무엇을 선택할까
Presigned PUT과 Presigned POST는 둘 다 브라우저 직접 업로드에 사용할 수 있지만 제약 표현 방식이 다르다.
| 항목 | Presigned PUT | Presigned POST |
|---|---|---|
| 요청 형태 | 객체 본문을 PUT | multipart/form-data POST |
| 구현 단순성 | 단일 파일에 간단 | form fields 구성 필요 |
| 크기 제한 | 앱 정책 + 사후 확인, 서명 조건 구성 | policy의 content-length-range 조건 활용 가능 |
| key·Content-Type 제한 | 서명된 요청과 헤더 일치 | policy condition으로 표현 |
| 브라우저 폼 업로드 | 직접 구성 | 자연스러움 |
클라이언트가 단일 객체를 올리고 서버에서 완료 검증을 수행한다면 PUT이 단순하다. 업로드 시점에 크기 범위를 S3 정책으로 강제하고 싶다면 POST를 고려한다.
Presigned POST의 개념적인 예시는 다음과 같다.
const result = await createPresignedPost(s3, {
Bucket: bucketName,
Key: objectKey,
Expires: 300,
Fields: {
"Content-Type": contentType,
"x-amz-meta-upload-id": uploadId,
},
Conditions: [
["content-length-range", 1, maxBytes],
["eq", "$Content-Type", contentType],
["eq", "$x-amz-meta-upload-id", uploadId],
],
});
클라이언트는 반환된 fields를 모두 FormData에 넣고 마지막에 파일을 추가한다.
const form = new FormData();
for (const [name, value] of Object.entries(prepared.fields)) {
form.append(name, value);
}
form.append("file", file);
await fetch(prepared.url, {
method: "POST",
body: form,
});
어느 방식이든 애플리케이션이 허용한 크기와 실제 업로드된 크기를 완료 단계에서 다시 비교한다.
보안 경계를 URL 하나로 착각하지 않기
Presigned URL은 임시 자격 증명처럼 취급해야 한다. URL을 가진 주체는 만료 전까지 서명된 작업을 수행할 수 있다.
짧은 만료 시간
사용자가 즉시 업로드하는 화면이라면 5~10분처럼 짧은 시간을 둔다. 무조건 긴 만료 시간을 주면 채팅, 브라우저 히스토리, 로그를 통해 유출되었을 때 악용 창이 커진다.
서명에 임시 자격 증명을 사용했다면 URL에 적힌 만료 시간보다 자격 증명이 먼저 끝날 수 있다. 반대로 URL을 발급했다고 해서 독립적인 revoke API가 생기는 것은 아니다.
단일 사용이라고 가정하지 않는다
공식 AWS guidance에 따르면 presigned request의 서명은 반복 사용될 수 있다. “한 번 업로드했으니 URL이 자동 폐기된다”는 보장이 없다. 같은 key에 다시 PUT하면 bucket versioning 설정에 따라 객체를 덮어쓰거나 새 버전을 만들 수 있다.
이를 줄이는 방법은 다음과 같다.
- key를 업로드 세션마다 새로 생성한다.
- 완료된 세션은 애플리케이션에서 다시 사용하지 못하게 한다.
- 만료 시간을 짧게 둔다.
- 객체 versioning과 후처리 상태를 고려한다.
- 정확히 한 번 의미가 필요하면 별도 상태와 검증을 둔다.
Bucket은 private으로 유지한다
업로드를 쉽게 하려고 public write나 public read를 열지 않는다. 다운로드도 인증이 필요하면 별도의 짧은 Presigned GET이나 CDN signed URL을 발급한다.
Bucket policy로 추가 guardrail을 둔다
조직 정책에 따라 s3:signatureAge, 네트워크 조건, 암호화 헤더 등을 검토할 수 있다. 단, 너무 강한 bucket policy는 AWS 서비스가 생성하는 presigned request나 모바일 네트워크 사용을 막을 수 있으므로 기존 사용을 먼저 관측한다.
URL과 원본 파일명을 로그에서 보호한다
Query string 전체, 원본 파일명, 사용자 식별자를 access log와 APM span에 남기는지 확인한다. 로그에는 uploadId, 익명화된 user ID, object key의 해시 정도만 필요한 경우가 많다.
사용자 인증, 업무 권한, 파일 내용 안전성, 정확히 한 번 업로드, 공개 범위는 Presigned URL이 대신 보장하지 않는다.
파일 검증과 후처리 파이프라인
API 서버가 파일 바이트를 보지 않으므로 업로드 완료 후 별도 검사 단계가 필요하다.
flowchart LR
A[quarantine prefix] --> B[S3 Event]
B --> C[Scanner Worker]
C --> D{안전한 파일?}
D -->|예| E[processed prefix]
D -->|아니오| F[REJECTED + 삭제/격리]
E --> G[썸네일/메타데이터 생성]
G --> H[READY]검사 항목은 용도에 따라 달라진다.
- magic bytes와 실제 파일 형식
- 이미지 디코딩 가능 여부와 최대 해상도
- 압축 파일의 파일 수와 압축 해제 크기
- PDF 구조와 페이지 수
- 악성코드 검사
- EXIF 위치 정보 제거
- 썸네일이나 미리보기 생성
사용자 콘텐츠를 바로 공개 prefix에 저장하지 않고 quarantine/에 둔 뒤 안전 판정 후 복사하거나 상태로 접근을 통제한다.
S3 event delivery와 worker 실행은 중복될 수 있으므로 후처리도 멱등하게 만든다.
const claimed = await uploadRepository.claimForScanning({
uploadId,
expectedStatus: "UPLOADED",
});
if (!claimed) {
return;
}
const result = await scanner.scan(objectKey);
await uploadRepository.completeScan(uploadId, result);
실패한 검사는 재시도 가능 오류와 영구 거절을 구분한다. scanner timeout은 재시도하지만 실제 형식 불일치는 REJECTED로 끝낸다.
CORS와 자주 만나는 실패
브라우저에서 S3로 다른 origin 요청을 보내므로 bucket CORS 설정이 필요하다.
[
{
"AllowedOrigins": ["https://app.example.test"],
"AllowedMethods": ["PUT", "POST"],
"AllowedHeaders": ["content-type", "x-amz-*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 300
}
]
운영에서는 * 대신 실제 origin과 필요한 method/header만 허용한다. CORS는 브라우저 정책이며 S3 IAM 권한을 부여하는 기능이 아니다.
자주 만나는 실패를 정리하면 다음과 같다.
| 증상 | 가능한 원인 | 확인할 것 |
|---|---|---|
| 브라우저 preflight 실패 | CORS origin·header 누락 | OPTIONS 응답과 bucket CORS |
SignatureDoesNotMatch |
서명한 헤더와 실제 헤더 불일치 | Content-Type, host, method |
AccessDenied |
IAM 또는 bucket policy 거부 | signer principal과 key prefix |
| 로컬은 되고 운영만 실패 | region·endpoint·clock 설정 차이 | S3 client region, 서버 시각 |
| 업로드는 성공했는데 완료 실패 | size/type/metadata 불일치 | HeadObject 결과 |
| 가끔 이전 파일이 보임 | 같은 key 재사용·캐시 | UUID key, versioning, CDN cache |
서명 오류를 디버깅하려고 전체 URL을 협업 채널에 붙이지 않는다. request ID, key, 서명된 헤더 목록, 만료 시각처럼 비밀이 아닌 정보로 조사한다.
대용량 파일은 Multipart Upload를 고려한다
수백 MB나 수 GB 파일은 단일 PUT 실패 시 처음부터 다시 보내야 한다. Multipart Upload는 파일을 여러 part로 나누고 실패한 part만 재전송할 수 있다.
흐름은 더 복잡하다.
- 서버가 multipart upload를 시작해
uploadId를 저장한다. - 각 part 번호에 대한 presigned URL을 발급한다.
- 클라이언트가 part를 제한된 동시성으로 업로드하고 ETag를 모은다.
- 서버가 소유권과 part 목록을 검증한 뒤 complete 요청을 수행한다.
- 중단된 upload는 abort하고 수명 주기 정책으로 정리한다.
type UploadedPart = {
partNumber: number;
etag: string;
};
async function uploadParts(
parts: Blob[],
urls: string[],
): Promise<UploadedPart[]> {
return mapWithConcurrency(parts, 4, async (part, index) => {
const response = await fetch(urls[index], {
method: "PUT",
body: part,
});
if (!response.ok) {
throw new PartUploadError(index + 1);
}
return {
partNumber: index + 1,
etag: response.headers.get("etag") ?? "",
};
});
}
동시성을 무제한으로 높이면 브라우저와 네트워크, S3 요청 수가 급증한다. 3~6개처럼 측정 가능한 제한부터 시작한다.
프로필 이미지처럼 작은 파일에 Multipart Upload를 적용할 필요는 없다. 재개 가능성의 가치가 상태 관리와 정리 비용보다 큰 대용량 파일에 사용한다.
테스트와 운영 지표
Presigned URL 기능은 URL 문자열이 생성되는지만 테스트해서는 부족하다.
정책 단위 테스트
it("프로필 이미지는 5MB를 초과할 수 없다", () => {
const policy = resolveUploadPolicy("PROFILE_IMAGE");
expect(() =>
policy.assertAllowed("image/png", 5 * 1024 * 1024 + 1),
).toThrow(FileTooLargeError);
});
통합 테스트
테스트 bucket이나 S3 호환 로컬 환경에서 실제 PUT과 HEAD 흐름을 검증한다.
it("서명된 헤더로 업로드한 객체만 완료할 수 있다", async () => {
const prepared = await prepareUpload(validCommand());
const response = await fetch(prepared.url, {
method: "PUT",
headers: prepared.headers,
body: fixturePng,
});
expect(response.ok).toBe(true);
const completed = await completeUpload(prepared.uploadId);
expect(completed.status).toBe("UPLOADED");
});
다음 실패도 확인한다.
- 만료된 URL
- 잘못된
Content-Type - 예상 크기와 실제 크기 불일치
- 다른 사용자의 완료 요청
- 완료 API 중복 호출
- 같은 key 재업로드
- 검사 worker 중복 이벤트
운영 지표
- 준비 요청 수 대비 실제 업로드 완료율
PENDING상태의 p95 체류 시간- S3 PUT 실패율과 상태 코드
- 파일 크기별 업로드 시간
- 완료 검증 실패 이유
- 검사 큐 지연과
REJECTED비율 - 오래된 multipart upload 수
- 사용자·IP별 URL 발급 빈도
발급 수와 완료 수 차이가 갑자기 커지면 클라이언트 버그, CORS 오류, 서명 만료 시간 부족을 의심할 수 있다. 특정 사용자가 짧은 시간에 URL을 대량 발급하면 rate limit과 권한 악용 여부를 확인한다.
비용도 관측해야 한다. 저장 용량뿐 아니라 PUT·HEAD·LIST 요청, 실패한 multipart part, 검사 작업의 컴퓨팅 비용이 쌓인다. PENDING과 REJECTED 객체에 Lifecycle rule을 두면 방치 비용을 줄일 수 있다.
정리
Presigned URL은 파일 업로드의 바이트 경로를 S3로 옮겨 API 서버의 bandwidth와 connection 점유를 크게 줄인다. 하지만 단순히 URL 한 줄을 발급하는 기능이 아니다.
- 인증과 업무 권한을 확인한 뒤 URL을 발급한다.
- 서버가 충돌하지 않는 객체 키를 만든다.
- 서명 역할은 특정 prefix의 최소 권한만 가진다.
- 만료 시간을 짧게 두고 URL을 로그에 남기지 않는다.
- 클라이언트가 주장한 크기와 형식을 업로드 후 다시 검증한다.
- 사용자 파일은 격리 후 검사하고 안전한 상태에서만 노출한다.
- Presigned request가 자동으로 단일 사용이 된다고 가정하지 않는다.
애플리케이션 서버를 파일 전송 경로에서 빼는 대신, 업로드 세션의 소유권과 상태 전이, 사후 검증을 더 분명하게 관리하는 것이 Presigned URL 설계의 핵심이다.